本文同步刊載於 「為你自己學 Python - CPython 專案簡介」

這個章節會介紹 CPython 專案的結構,以及如譯編譯專案,我們會試著從原始碼來編譯出自己魔改過的 Python,來感受一下當 Python Core Dev 是什麼感覺(開玩笑的)!
首先,要開始讀原始碼,得先把專案從 GitHub 上拉一份下來:
$ git clone git@github.com:python/cpython.git
扣掉一些比較不重要的檔案,剛從 GitHub 拉下來的 CPython 專案的目錄大概是這樣:
CPython
├── Doc
├── Grammar
├── Include
├── Lib
├── Mac
├── Misc
├── Modules
├── Objects
├── PC
├── PCbuild
├── Parser
├── Programs
├── Python
└── Tools
先簡單介紹一下每個目錄裡放的檔案:
Doc:這很好猜,就如同它的名字一樣,就是放文件的地方,是用 reStructuredText(.rst) 格式編寫的,如果晚上睡不著可以拿出來啃,助眠效果滿點!Grammar:定義 Python 語法規則解析用的文件。Include:專案裡 C 語言用到的 Header 檔案,後續如果想要幫 CPython 寫 extension 的話,應該都會用到這裡的檔案。Lib:標準函式庫,這目錄裡的東西是用 Python 寫的,如果略懂 Python 的話,這個目錄裡的東西應該讀起來會比較親切。Modules:同 Lib 目錄,不過這裡的內容是用 C 語言寫的。Mac:這是給 Mac 作業系統用的東西。Misc:雜七雜八的檔案,依我自己個人的習慣,我開這種目錄就是用來放那種不知道怎麼分類的東西。Objects:所有 Python 內建物件的原始碼在這裡,例如 str 或是 list 都在這裡。PCbuild:這是給 Windows 作業系統用的東西,特別是 Visual Studio,裡面有可以直接點兩下就能開啟的專案檔。PC:同上,但是是給比較早期的 Windows 版本用的。大多數已經過時,但有些文件仍然是為了相容性而保留下來。Parser:把 .py 檔轉換成 Python 看的懂的 Token 的程式碼在這裡,難度有一點高。Programs:存放與 CPython 執行檔相關的原始碼。Python:CPython 直譯器(Interpreter)的原始碼在這裡,難度比較高,但對直譯器有興趣的可以看看。Tools:一些開發和維護 Python 的輔助工具。以這整個系列單元來說,比較常看到的應該是 Include、Lib、Modules、Objects 和 Python 這幾個目錄,這些都是 CPython 直譯器的核心原始碼,如果想要了解 Python 的運作原理,可能就得多一些時間泡在這些目錄裡。
專案下載之後,先 cd 切換到目錄裡並執行 ./configure 指令:
$ ./configure
如果在 ./configure 後面加上 --prefix 參數,像這樣:
$ ./configure --prefix=/tmp/my-python
有特別加上 --prefix 的話,之後如果執行 make install 指令的時候,就會把 Python 以及相關的程式安裝到 /tmp/my-python 目錄裡。以目前來說我並沒打算執行 make install 進行安裝,所以可以先不加 --prefix 參數,等到後續有機會執行其它外部程式例如 pip 的時候再加即可。
剛才這個 ./configure 指令會在畫面上不斷的跳出一堆我看不懂的資訊,這是在檢查系統環境,看看有沒有缺什麼套件或函式庫,沒問題的話會產生一個 Makefile 檔案,這個檔案會告訴 make 指令待會應該要怎麼編譯整個專案。
接著執行 make 指令,這個指令會根據剛才產生的 Makefile 檔案來編譯整個專案:
$ make
這個過程可能會花一點點時間,如果整個編譯沒出錯的話,應該會在根目錄有個 python.exe 執行檔,即使在 macOS 上也是叫這個名字。我知道在 macOS 看到 .exe 可能有點不太習慣,不過這是刻意的設計,原因是因為在 CPython 專案裡原本就有個 Python/ 目錄,所以刻意選擇 python.exe 而不是 python 避免跟這個目錄發生衝突。
如果想要「安裝」剛才我們自己編譯出來的 Python 版本,可以執行:
$ make install
如果剛才在 ./configure 指令後面有加上 --prefix 參數,那這個指令會把 Python 安裝到指定的目錄裡。不過就算不安裝也沒關係,執行剛才編譯出來的 python.exe 也可以直接執行。接下來,執行剛才編譯出來的 python.exe,就會看到我們熟悉的 REPL 環境了:
$ ./python.exe
Python 3.12.6+ (heads/3.12:b2a7d718e3b, Sep 15 2024, 23:31:57) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.version
'3.12.6+ (heads/3.12:b2a7d718e3b, Sep 15 2024, 23:31:57) [Clang 15.0.0 (clang-1500.3.9.4)]'
在版號後面的 + 表示這個 Python 版本並不是一個正式發行的版本,可能是「開發版本」或我們自己拿原始碼來編譯的「自訂版本」。
我們在上個章節練習過使用 printf() 來印點東西,這回我們動手改一點 CPython 原始碼。例如,我想要的效果是一進到 REPL 的時候就先印個 Hello,離開 REPL 的時候也有禮貌的說聲 Goodbye,禮多人不怪嘛!所以我們得先找到進到 REPL 的那段程式碼。這段程式碼在 Python/pythonrun.c 裡,翻一下 _PyRun_InteractiveLoopObject() 函數,應該會看到一個 do...while... 迴圈,這迴圈就是全名 Read-Eval-Print Loop 的 REPL 的那個 Loop:
// 檔名:Python/pythonrun.c
int
_PyRun_InteractiveLoopObject(FILE *fp, PyObject *filename, PyCompilerFlags *flags)
{
PyCompilerFlags local_flags = _PyCompilerFlags_INIT;
// ... 略 ...
do {
ret = PyRun_InteractiveOneObjectEx(fp, filename, flags);
// ... 略 ...
} while (ret != E_EOF);
return err;
}
這段程式應該不難懂,重點就在迴圈裡而已。所以如果我想在進到 REPL 迴圈之前先打聲招呼,應該只要在 do 前面來個 printf() 就好。為了感覺自己有在寫點程式,我刻意在這個檔案寫一個 say_something() 函數,其實它就只是把傳進去的字串印出來而已:
// 檔名:Python/pythonrun.c
void
say_something(const char *message)
{
printf("==============\n");
printf("%s\n", message);
printf("==============\n");
}
因為待會要在 _PyRun_InteractiveLoopObject() 函數裡呼叫 say_something(),所以要把 say_something() 寫在 _PyRun_InteractiveLoopObject() 的前面。然後就可以準備來呼叫它了:
// 檔名:Python/pythonrun.c
int
_PyRun_InteractiveLoopObject(FILE *fp, PyObject *filename, PyCompilerFlags *flags)
{
PyCompilerFlags local_flags = _PyCompilerFlags_INIT;
// ... 略 ...
say_something("Hello CPython"); // 加這行
do {
ret = PyRun_InteractiveOneObjectEx(fp, filename, flags);
// ... 略 ...
} while (ret != E_EOF);
say_something("Bye"); // 加這行
return err;
}
這樣就能在進到 REPL 的時候印出 Hello CPython,離開 REPL 的時候印出 Bye 了。不像 Python 或 JavaScript 之類的程式語言改完立刻執行就能看到效果,C 語言得要先編譯才行。所以接著需要執行 make 指令重新編譯 CPython,不過這次不會整個專案重新編譯,所以速度上應該會比上次快一些。
編譯完之後再重新執行一次:
$ ./python.exe
Python 3.12.6+ (heads/3.12-dirty:b2a7d718e3b, Sep 16 2024, 14:46:05) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
=============
Hello CPython
=============
>>> 1 + 2
3
>>> print("你好")
你好
>>> ^D
=============
Bye
=============
一進到 REPL 就會看節 Hello CPython,在按下 Ctrl+D 離開 REPL 的時候也會印出 Bye 字樣,這樣就算是成功了!
不過這還有個小問題,因為要離開 REPL 除了 Ctrl + D 之外還可以輸入 exit(),但現在輸入 exit() 並不會印出 Bye,但這還容易看出來的,你看原本的 do...while... 迴圈就只有判斷 ret != E_EOF 而已。這個問題待會我們再來處理,主要是這個 say_something() 函數之後可能在別的地方還會用到,所以我想把它抽出來,後續要用的時候就可以重複呼叫這個函數。
也趁這個機會學一下在 C 語言裡怎麼把函數整理成模組!
在 C 語言裡要定義模組,大概就是先在某個 .h 檔案宣告函數的原型,然後在另一個 .c 檔裡實作這個函數的功能,這在 C 語言是很常見的做法。根據前面對 CPython 的介紹,這種要被引入的 .h 檔案通常會放在 Include 目錄裡,而用 C 語言寫的 .c 檔案通常會放在 Modules、Python 或 Objects 目錄裡。Modules 目錄通常用來放擴展模組(C Extension),而核心功能通常在 Python 或 Objects 目錄中。由於我們加的這個 say_something() 函數算是修改直譯器的行為,我把它擺在 Python 目錄更為合適。
我們就照著 CPython 的慣例,首先在 Include 目錄裡建立一個 greeting.h 檔案。名字你可以自己決定,檔案內容如下
// 檔案:Include/greeting.h
#ifndef _PY_GREETING_H
#define _PY_GREETING_H
extern void say_something(const char *message);
#endif
前面兩行的 #ifndef 和 #define 是 Header Guard,是一種為了避免這個檔案被重複引入從而避免編譯錯誤的小技巧,這裡的 ifndef 是「if not defined」的縮寫,而 _PY_GREETING_H 就只是我自己隨便編的名字,只要不跟其它的重複就好。接著我們在 Python 目錄裡建立一個 greeting.c 檔案,檔案內容如下:
// 檔案:Python/greeting.c
#include <stdio.h>
#include "greeting.h"
void say_something(const char *message)
{
printf("=============\n");
printf("%s\n", message); // 使用傳入的 message
printf("=============\n");
}
內容跟剛才寫沒什麼差別,只是多引入了 greeting.h 檔案,這樣編譯器才知道 say_something() 函數的原型。
接著我們要告訴 CPython 要把這兩個檔案編譯進去,這樣才能在 CPython 的原始碼裡使用 say_something() 函數。在 CPython 專案裡,要編譯的檔案都會在 Makefile.pre.in 裡列出來,這個檔案是用來產生 Makefile 的模板,所以我們要在這裡加上 greeting.c 這個檔案,搜尋一下 PYTHON_OBJS,把我們自己寫的 greeting 找個地方加上去:
// 檔案:Makefile.pre.in
PYTHON_OBJS= \
Python/_warnings.o \
... 略 ...
Python/suggestions.o \
Python/perf_trampoline.o \
Python/greeting.o \ <-- 加上這行
Python/$(DYNLOADFILE) \
$(LIBOBJS) \
$(MACHDEP_OBJS) \
$(DTRACE_OBJS) \
@PLATFORM_OBJS@
存檔之後需要重新執行 ./configure 指令,請它再次幫我們產生 Makefile。接著再次執行 make 指令重新編譯 CPython,這樣就會把 greeting.c 編譯成 greeting.o。
這樣一來,我們就可以我們想要的地方,例如 Python/pythonrun.c 裡呼叫 say_something() 函數了:
// 檔案:Python/pythonrun.c
#include "pycore_pylifecycle.h" // _Py_UnhandledKeyboardInterrupt
#include "pycore_pystate.h" // _PyInterpreterState_GET()
#include "pycore_sysmodule.h" // _PySys_Audit()
#include "pycore_traceback.h" // _PyTraceBack_Print_Indented()
#include "greeting.h" // <-- 加上這個
// ... 略 ...
這樣以後想要用 say_something() 函數的時候,只要引入 greeting.h 就可以了。
剛才在 REPL 裡輸入 exit() 不會觸發 say_something() 的問題,這是因為 exit() 會直接結束程式,不會進到迴圈裡面。所以我們要在 Python/pythonrun.c 裡找到 handle_system_exit() 函數,這個函數是用來處理離開 REPL 的,我們可以在裡面加上一行 say_something("Bye");:
// 檔案:Python/pythonrun.c
static void
handle_system_exit(void)
{
int exitcode;
if (_Py_HandleSystemExit(&exitcode)) {
say_something("Bye"); // <-- 加這行
Py_Exit(exitcode);
}
}
重新再 make 一次,應該就行了:
$ ./python.exe
Python 3.12.6+ (heads/3.12-dirty:b2a7d718e3b, Sep 16 2024, 15:41:49) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
=============
Hello CPython
=============
>>> print("Hey")
Hey
>>> exit()
=============
Bye
=============
Good,打完收工!
大家在學 Python 的時候,可能聽過「在 Python 裡什麼東西都是『物件』」的說法,所以下個章節我們就先從這所謂的「物件」開始看吧,看看它到底是什麼東西。
本文同步刊載於 「為你自己學 Python - CPython 專案簡介」
建議切換到和作者同分支,因為新版本有改動,不再使用_PyRun_InteractiveLoopObject
切換方式:
git checkout 3.12
git reset --hard b2a7d718e3b